Redis 底层原理与性能

Redis 系统讲解第四篇:为什么快。书上看的原理篇读书笔记——SDS、渐进式 rehash、跳表、单线程模型、淘汰策略,最后落到"大 key / 热 key / 事务与 Lua"这些工程上真正会撞上的问题。

为什么单线程还能这么快

纯内存操作(微秒级) + 单线程无锁无上下文切换 + IO 多路复用(epoll 同时监听万个连接)

书里的经典比喻:多线程像多个窗口排队办业务(要协调),Redis 是一个超快的柜员 + 叫号系统(epoll)——瓶颈从"调度"转移到"单个操作的耗时",这就是为什么大 key 是毒瘤:一个操作慢,所有请求排队。

Redis 6.0 的多线程只用于网络读写和协议解析,命令执行仍是单线程——所以"单线程模型"的原子性结论至今成立。

SDS:Redis 自己的字符串

C 字符串的三个痛点 → SDS 的三个设计:

C 字符串问题 SDS 方案
strlen 要 O(n) 扫到 \0 头部记录 len,取长度 O(1)
二进制不安全(\0 截断) 按 len 读取,二进制安全(能存图片/序列化数据)
拼接必 realloc 空间预分配 + 惰性释放,减少 realloc

dict 与渐进式 rehash

Redis 的全局键空间就是一张 dict。扩容时不一次搬完(百万 key 搬一夜就卡死),而是渐进式 rehash:新旧两张表同时挂着,每次增删改查顺手搬一个桶,后台定时任务也帮着搬——期间读先查旧表再查新表,写只进新表。

💡 这是"把一次大卡顿摊薄成无数次小卡顿"的经典设计——和 Redis 一贯的"不能阻塞主线程"哲学一脉相承。

跳表(skiplist):ZSet 的排序引擎

多层级链表:上层是下层的"快车道",查询从顶层往下贪心,期望 O(log n)。

书里对比过的"为什么不用平衡树/红黑树":跳表实现简单、范围查询天然友好(底层是有序链表,ZRANGE 顺着走就行)、增删只改前后指针不做旋转。Redis 作者的原话大意:功能差不多时,选好写的那个

ZSet 实际是 skiplist + dict 双结构:dict 负责 member→score 的 O(1) 查分,skiplist 负责按 score 排序的范围查询——空间换时间。

listpack 与数据结构的"小数据紧凑化"

老版本用 ziplist(紧凑连续存储),缺陷是连锁更新(改一个元素可能引发后续所有节点的 prevlen 字段级联重写)。新版本逐步换成 listpack(每个元素只记自己的长度,天然无连锁更新)。配合 quicklist(List:双向链表把多个 listpack 串起来),构成"大数据用链式、小数据用紧凑"的通用思路。

内存淘汰策略八选一

策略 范围 算法
noeviction(默认) 不淘汰,内存满写报错
allkeys-lru / volatile-lru 全部 / 仅带 TTL 最近最少使用
allkeys-lfu / volatile-lfu 同上 最不经常使用(4.0+,计数衰减)
allkeys-random / volatile-random 同上 随机
volatile-ttl 仅带 TTL 剩余寿命最短的先走

LRU 是近似实现(采样 5 个 key 挑最久未用的,省内存);LFU 用 lru 字段的前 16 位存对数计数器 + 衰减。

事务与 Lua:原子性但不回滚

MULTI ... EXEC 的两个反直觉点:

  • 入队报错(命令不存在)→ 整个事务不执行
  • 执行时出错(类型错误,如对 List 执行 LPUSH 到 String)→ 其他命令照常执行,不回滚——Redis 追求简单快速,不提供回滚

所以"逻辑性错误"防不住,Lua 脚本才是真正的原子利器:整段脚本单线程原子执行,还能做条件判断(锁校验、限流扣减都靠它)。脚本是原子的,但不是隔离的"事务"——脚本执行慢了照样阻塞所有人,Lua 里别写循环大逻辑。

大 key 与热 key 治理

问题 危害 治理
大 key 读写阻塞、删除卡顿、迁移慢 拆分(Hash 分桶)、UNLINK 异步删、--bigkeys 排查
热 key 单 key 打满单线程 本地缓存兜一层、key 加后缀打散(读时随机挑一份)、读写分离

💡 我的总记忆钩子:Redis 的性能哲学 = 永远别让主线程等——所以异步删(UNLINK)、渐进式 rehash、bgsave 子进程、6.0 把网络 IO 挪到辅助线程,全是同一句话的不同实现。


⬅️ 04-Redis 持久化与高可用 🏠 00-数据库 ➡️ 02-mongoDB